fix: bound the kuri-bind reflective plan/scan caches - #141
Merged
Conversation
PlanCompiler.plans and KotlinReflectMemberScanner.scanCache were plain ConcurrentHashMap<KClass<*>, ...> with no size limit, living inside the process-lifetime KuriBind.executor singleton. A KClass strongly retains its backing Class and, through it, its ClassLoader, so every distinct type ever bound - including nested value types routed through runtimeValueIsBindable - was cached and its ClassLoader pinned for the life of the process. In a host that mints classes dynamically (per-deployment ClassLoaders, hot redeploy, bytecode-generated proxies), that accumulates without bound. Introduce BoundedCache, a small thread-safe LRU backed by a single access-ordered LinkedHashMap guarded by a ReentrantLock, and swap both caches onto it with a 2048-entry default. A weak-key map was considered first, but both cached values hold a strong path back to the class used as the key (TypePlan stores its KClass directly; ScannedMember's reader closures hold kotlin-reflect KProperty1 instances that reference their declaring class) - weak keys would not actually let those classes become unreachable, so a bounded LRU is the fix that works. Closes #89
…ut KDoc The concurrent double-checked-locking test never asserted that a race actually happened, so it could pass even if the discard-on-second-check path were broken by scheduling luck. Force every racer to enter compute before any can insert, and assert compute ran once per racer. Add coverage for concurrent eviction across distinct keys past the cap, and for the liveness property that a slow compute for one key doesn't block a getOrPut for a different key. Also corrects getOrPut's @param compute KDoc, which warned of a deadlock that can't happen: compute always runs with the lock released, so a reentrant or cross-thread call is safe. The real risk of recursing on the same key is unbounded recursion, not a deadlock.
This was referenced Jul 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
PlanCompiler.plansandKotlinReflectMemberScanner.scanCachewere unboundedConcurrentHashMap<KClass<*>, ...>instances living inside the process-lifetimeKuriBind.executorsingleton. AKClassstrongly retains its backingClassand, through it, itsClassLoader, so every distinct type ever bound (including nested value types routed throughruntimeValueIsBindable) stayed cached, pinning itsClassLoader, for the life of the process.BoundedCache, a small thread-safe LRU (a single access-orderedLinkedHashMapguarded by aReentrantLock), and switched both caches onto it with a 2048-entry default, evicting the least-recently-used entry once the cap is reached.TypePlanstores itsKClassdirectly, andScannedMember's reader closures holdkotlin-reflectKProperty1instances that reference their declaring class, so the cached value keeps the key (and itsClassLoader) strongly reachable regardless of how the map's key reference is held. A bounded LRU sidesteps that entirely.Test plan
./gradlew :kuri-bind:jvmTest— newBoundedCacheTest(size-bound eviction, LRU-order eviction, cache-hit identity, concurrent-miss convergence),PlanCompilerCacheTest, andMemberScannerCacheTest(both prove a type evicted by newer lookups is recompiled rather than retained forever), plus the full existing kuri-bind suite./gradlew :kuri-bind:ktlintCheck./gradlew :kuri-bind:detekt./gradlew :kuri-bind:apiCheck— no public API surface changed (BoundedCacheis internal; the new constructor parameters onPlanCompiler/KotlinReflectMemberScannerare internal-only and default-valued), so noapiDumpneededjvm, sojsNodeTest/wasmJsNodeTestdon't apply to this moduleCloses #89